Skip to content

Fix modified_beam_search hallucination/empty text for NeMo TDT#3657

Open
kittysoftpaw0510 wants to merge 2 commits into
k2-fsa:masterfrom
kittysoftpaw0510:fix/nemo-tdt-modified-beam-search-hallucination
Open

Fix modified_beam_search hallucination/empty text for NeMo TDT#3657
kittysoftpaw0510 wants to merge 2 commits into
k2-fsa:masterfrom
kittysoftpaw0510:fix/nemo-tdt-modified-beam-search-hallucination

Conversation

@kittysoftpaw0510

@kittysoftpaw0510 kittysoftpaw0510 commented Jun 3, 2026

Copy link
Copy Markdown

Fixes #3267

Problem

modified_beam_search with NeMo TDT models (e.g. Parakeet-TDT-0.6b-v2) produces hallucinated output ("Yeah.") or empty text ~20% of the time.

Root Cause

Three bugs in OfflineTransducerModifiedBeamSearchNeMoDecoder, identified by comparing against the working greedy decoder (DecodeOneTDT):

1. Missing blank+skip==0 guard (primary cause)

The greedy TDT decoder has a critical safety check:

if (y == blank_id && skip == 0) {
    skip = 1;  // Force advance to prevent infinite loop
}

The beam search decoder was missing this. When blank was predicted with duration 0, the hypothesis stayed on the same frame with num_symbols reset to 0, creating a loop that produced empty or hallucinated output.

2. duration_log_prob added to blank candidates

The duration probability was applied to ALL candidates uniformly. For blank tokens, the decoder state is unchanged and frame advancement uses different logic -- including the duration score skewed beam ranking toward blank-heavy hypotheses that raced through frames without emitting tokens.

3. max_symbols_per_frame = 10 vs greedy decoder 5

The higher limit allowed more spurious token emissions per frame, contributing to hallucinated short phrases like "Yeah."

Changes

  • Blank candidates now scored without duration_log_prob
  • Blank with TDT always advances by std::max(1, predicted_skip) frames (explicit guard)
  • max_symbols_per_frame reduced from 10 to 5 to match greedy TDT decoder

Test plan

  • Run modified_beam_search on audio that previously produced "Yeah." or empty text
  • Verify hotword boosting still works correctly with Parakeet-TDT models
  • Compare WER between greedy and modified_beam_search on a test set

Summary by CodeRabbit

  • Refactor
    • Cap per-frame emitted non-blank symbols to 5 in time-dependent mode, reducing spurious emissions.
    • Split blank/unknown vs non-blank scoring paths: blanks no longer use duration contribution and reset per-frame counts; non-blanks include duration scores and update decoder state.
    • Preserve and reorganize context-graph boosting so it’s applied at the appropriate expansion stage.

Fixes three bugs in OfflineTransducerModifiedBeamSearchNeMoDecoder that
caused ~20% of transcriptions to produce hallucinated output or empty
text when using modified_beam_search with Parakeet-TDT models:

1. Missing blank+skip==0 guard: When the TDT model predicted blank with
   duration 0, the hypothesis stayed on the same frame with num_symbols
   reset to 0, causing an infinite loop that produced empty or
   hallucinated output. Now blank always advances by at least 1 frame,
   matching the greedy decoder.

2. duration_log_prob incorrectly added to blank candidates: The duration
   probability was applied uniformly to all candidates. For blank tokens
   the decoder state is unchanged and frame advancement follows different
   logic, so including the duration score skewed beam ranking. Now only
   non-blank candidates include the duration component.

3. max_symbols_per_frame was 10 vs greedy decoder 5: The higher limit
   allowed more spurious token emissions per frame. Aligned with the
   greedy TDT decoder.

Fixes k2-fsa#3267
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jun 3, 2026
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3ef08cfc-28d1-4f90-ba24-112a6adc35c2

📥 Commits

Reviewing files that changed from the base of the PR and between 4391726 and 8677628.

📒 Files selected for processing (1)
  • sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc
🚧 Files skipped from review as they are similar to previous changes (1)
  • sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc

📝 Walkthrough

Walkthrough

Reduce per-frame non-blank emissions to 5 in TDT mode and refactor beam expansion: blank/unk use token-only scoring with forced frame advance ≥1 and reset per-frame symbol count; non-blank use token+duration scoring, advance by predicted_skip, update decoder state and num_symbols, and apply context-graph scoring during non-blank expansion.

Changes

TDT Hypothesis Scoring and Frame Advancement

Layer / File(s) Summary
TDT hypothesis scoring and frame advancement
sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc
Per-frame symbol cap reduced from 10 to 5 in TDT; hypothesis expansion refactored to remove a shared token_log_prob and implement separate paths: blank/unk uses only token log-prob, forces TDT frame advance ≥1 and resets num_symbols without changing decoder state; non-blank accumulates token + selected duration log-prob, advances decoder state, advances frame by predicted_skip in TDT and updates num_symbols when predicted_skip==0; context-graph forward/score is applied in non-blank expansion. Comment updated to clarify TDT vs non-TDT non-blank behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • k2-fsa/sherpa-onnx#2606: Enforces per-frame token limits and adjusts TDT skip/frame advancement and token scoring computation.
  • k2-fsa/sherpa-onnx#3589: Modifies the same NeMo offline transducer modified beam search decoder, adjusting hypothesis expansion scoring logic and per-hypothesis frame-advance/symbol-count behavior.
  • k2-fsa/sherpa-onnx#2423: Also caps non-blank symbol emissions per encoder frame across decoding modes.

Suggested reviewers

  • csukuangfj

Poem

"I nibble logs and hop through beams,
Five tokens now bound in TDT dreams,
Blank hops forward, at least a one-step beat,
Non-blank adds duration to keep the flow neat,
A rabbit cheers — decoder strides light on its feet." 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: fixing hallucination and empty text issues in modified beam search for NeMo TDT models.
Linked Issues check ✅ Passed The pull request code changes directly address all three root causes identified in issue #3267: blank candidates no longer get duration_log_prob, blank tokens now force minimum frame advance via max(1, predicted_skip), and max_symbols_per_frame is reduced to 5 for TDT.
Out of Scope Changes check ✅ Passed All changes are scoped to the OfflineTransducerModifiedBeamSearchNeMoDecoder file and directly address the identified hallucination issues without introducing unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Infer (1.2.0)
sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc

sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc:5:10: fatal error: 'sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.h' file not found
5 | #include "sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.h"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
Error: the following clang command did not run successfully:
/opt/infer-linux-x86_64-v1.2.0/lib/infer/facebook-clang-plugins/clang/install/bin/clang-18
@/tmp/coderabbit-infer/867762892495a6bf1a2b031699906aaa73217e90-0df667c9c3d6f094/tmp/clang_command_.tmp.cc4102.txt
++Contents of '/tmp/coderabbit-infer/867762892495a6bf1a2b031699906aaa73217e90-0df667c9c3d6f094/tmp/clang_command_.tmp.cc4102.txt':
"-cc1" "-load"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../../facebook-clang-plugins/libtooling/build/FacebookClangPlugin.dylib"
"-add-plugin" "BiniouASTExporter" "-plugin-arg-BiniouASTEx

... [truncated 1382 characters] ...

tem" "/usr/local/include" "-internal-isystem"
"/usr/lib/gcc/x86_64-linux-gnu/12/../../../../x86_64-linux-gnu/include"
"-internal-externc-isystem" "/usr/include/x86_64-linux-gnu"
"-internal-externc-isystem" "/include" "-internal-externc-isystem"
"/usr/include" "-Wno-ignored-optimization-argument" "-Wno-everything"
"-fdeprecated-macro" "-ferror-limit" "19" "-fgnuc-version=4.2.1"
"-fskip-odr-check-in-gmf" "-fcxx-exceptions" "-fexceptions"
"-D__GCC_HAVE_DWARF2_CFI_ASM=1" "-o"
"/tmp/coderabbit-infer/0df667c9c3d6f094/file.o" "-x" "c++"
"sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc"
"-O0" "-fno-builtin" "-include"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../lib/clang_wrappers/global_defines.h"
"-Wno-everything"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the offline transducer modified beam search decoder for NeMo models, reducing the maximum symbols per frame and refining the scoring and frame-advancing logic for TDT models to align with the greedy decoder. A review comment suggests making the reduction of max_symbols_per_frame conditional on is_tdt_ to avoid potential regressions in non-TDT models.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

int32_t vocab_size = model_->VocabSize();
int32_t blank_id = vocab_size - 1; // NeMo models have blank at the end
int32_t max_symbols_per_frame = 10;
int32_t max_symbols_per_frame = 5; // Match greedy TDT decoder limit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reducing max_symbols_per_frame to 5 unconditionally might cause regressions or search errors (deletion errors) for non-TDT NeMo models, which previously used a limit of 10. It is safer to make this limit conditional on is_tdt_ so that non-TDT models retain their original behavior.

Suggested change
int32_t max_symbols_per_frame = 5; // Match greedy TDT decoder limit
int32_t max_symbols_per_frame = is_tdt_ ? 5 : 10; // Match greedy TDT decoder limit

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc`:
- Line 97: The tight per-frame symbol cap was applied unconditionally by setting
max_symbols_per_frame = 5; restrict this TDT-only change by gating it on is_tdt_
(or revert to the prior default for non-TDT paths) so non-TDT branches in
OfflineTransducerModifiedBeamSearchNemoDecoder that intentionally allow repeated
same-frame emissions (see the non-TDT handling around the repeated-emission
logic and the zero-cost force-advance/ predicted_skip use) are unaffected;
update the initialization of max_symbols_per_frame to use the smaller value only
when is_tdt_ is true (or assign the previous larger default when !is_tdt_), and
ensure the TDT-specific std::max(1, predicted_skip) guard remains applied only
for the TDT path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d7f74c68-b324-4b71-8496-eb641e406404

📥 Commits

Reviewing files that changed from the base of the PR and between 6c883e7 and 4391726.

📒 Files selected for processing (1)
  • sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc

Comment thread sherpa-onnx/csrc/offline-transducer-modified-beam-search-nemo-decoder.cc Outdated
Non-TDT NeMo models intentionally allow up to 10 same-frame emissions.
Lowering the cap unconditionally could cause deletion errors for those
models. Keep 10 for non-TDT, use 5 only for TDT (matching its greedy
decoder).

Addresses review feedback from k2-fsa#3657.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@csukuangfj

Copy link
Copy Markdown
Collaborator

Thank you for your contribution!

Could you provide a test WAV file that reproduces the bug this PR is intended to fix?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

modified_beam_search with NeMo TDT (Parakeet) hallucinates or returns empty text ~20% of the time; greedy_search works

2 participants